#!/bin/bash                                                                                                           
# File Name    : main.sh
# Author       : moshan
# mail         : mo_shan@yeah.net
# Version      : 2.0
# Created Time : 2020-03-07 07:39:53
# Update Time  : 2021-03-05 22:03:53
# Function     : 建表 / 改表 / 删表 / 清表
#########################################################################
work_dir="/data/git/myonlineddl"                                  #工作目录
localhost_ip="$(/sbin/ip a 2>/dev/null|grep inet|grep brd|grep scope|awk '{print $2}'|awk -F'/' '{print $1}'|head -1)" || true   #获取本机ip
osc_log_dir="${work_dir}/logs"
[ ! -d "${osc_log_dir}" ] && mkdir -p ${osc_log_dir}
log_file="${osc_log_dir}/main.log"
end_time='$(date "+%F %H:%M:%S")'                                #脚本执行结束时间, 这里用单引号, 目的是让其在这里不被解析成时间, 等调用的时候再解析成时间
trap "trap - SIGTERM && sleep 1 && eval echo \"[ ${end_time} ] [ 提示 ] [ ${localhost_ip} ] [ ${0} ] [ \"执行结束 : ${s_n} ${*}\" ]\"|tee -a ${log_file}" SIGINT SIGTERM EXIT
set -e -o pipefail
s_n="$(awk -F/ '{print $NF}' <<< "${0}")"                        #脚本名script_name简写
script_start_time="$(date +%s)"                                  #脚本开始时间, 主要是要来判断脚本的执行时间, 要求脚本执行时间大于10s, 要不然回调可能有问题
_PPID="${$}"                                                     #当前改表任务的PPID
script_file_name="$(awk -F/ '{print $NF}' <<< "${0}"|awk -F. '{print $1}'|awk -F/ '{print $NF}')"
#沿用原来的PATH, 可能有部分PATH用不上
export PATH="/usr/local/mysql56/bin:${work_dir}/bin:${PATH}"      #加载PATH, 默认使用mysql 5.6, 如果是8.0版本, 后面会重新加载PATH
mysql_user="myonlineddl"                                          #线上改表用户, 要求要有管理员权限
mysql_pass="myonlineddlpassword"                                  #线上改表用户的密码
dbadb_host="192.168.1.10"
dbadb_port="3306"
dbadb_db="myonlineddl"
test_user="${mysql_user}"                                         #不要和线上的一致, 测试环境改表用户
test_pass="${mysql_pass}"                                         #不要和线上的一致
sql_file_dir="${osc_log_dir}/sql"                                 #从源端拷贝sql文件保存的目录
osc_test_res_log_dir="${osc_log_dir}/test"                        #测试环境执行状态保存日志的目录, 保留30天
osc_execute_res_log_dir="${osc_log_dir}/execute"                  #线上环境执行状态保存日志的目录, 保留30天
script_execute_res_log_dir="${osc_log_dir}/script"                #线上改表脚本保存日志的目录, 保留30天
script_queue_dir="${osc_log_dir}/queue"                           #队列目录, 同一个表同一时刻多个任务将依次执行
script_execute_drop_dir="${osc_log_dir}/drop"                     #清理表日志目录, 延迟删除的操作日志目录
script_execute_mark_dir="${osc_log_dir}/mark"                     #osc工具运行标志文件
script_queue_file="${script_queue_dir}/port_db_table_pid"         #队列文件名, 每个任务对应一个队列文件名
check_connect_timeout="300"                                       #检查连接数的时间, 如果这个时间阈值持续超过限制的连接数就报错, 终止改表
send_mail_script="python ${work_dir}/bin/send_mail.py"


#execute execute_opt结合使用, execute_opt只是控制开关, 进到开关里面由execute控制, -e 0|1控制的是execute_opt的值, 可以不用关注execute变量的值
execute_opt="1"                                                   #execute_opt默认是1, 就是说当再测试环境跑完, 没报错就会到线上去执行, 看不懂的就不用在意了
execute="0"                                                       #在测试环境执行的时候的控制开关, 0表示不在线上环境执行, 默认是0, 当再测试环境都跑完没报错后这个值会置为1
ghost_to_ptosc=0                                                  #遇到ghost到ptosc的控制变量
wait_mdl_time="0"
check_mdl_timeout="10"                                            #检查mdl等待的时间, 如果超过这个阈值还有mdl等待, 则进行相应处理. 默认是10s, ghost 30s
kill_master_for_null_query="0"                                    #kill掉master的空连接标志变量, 当这个值是0的时候才会去kill空连接, kill完空连接后置为1
pwd_path="$(pwd)"                                                 #保存当前目录, 作用于下文删除日志的时候, 需要返回当前目录.
log_save_time="30"                                                #日志保存时间30天
slave_max_sbm="600"                                               #复制延迟再这个时间之内, 会先等待这个时间后再判断延迟是否大于slave_mark_sbm
slave_mark_sbm="10"                                               #复制延迟超过这个时间就不允许执行改表操作
wait_time_for_work="43200"                                        #如果遇到相同的表已经存在改表任务了, 就等待这个时间, 12小时
html_dir="${work_dir}/html"                                       #html存放目录
send_script="send_mail.py"                        #发送邮件脚本
print_color="0"                                                   #输出的日志是否根据日志级别带颜色, error红色, warn黄色, 0表示不按照级别输出颜色
_mysql_db_=""                                                     #这个变量是保存库名, 给库名添加反引号, 防止用户的库名带中划线或者保留字
disk_alarm="85"                                                   #当改表的端口的任何节点的磁盘空间使用率超过这个值且剩余大小小于free_disk就会触发清理binlog策略
free_disk="314572800"                                             #当改表的端口的任何节点的磁盘空间不足这个值就触发清理binlog策略, 300G
value_of_task_opt=(stop_task pause_task start_task 
                chunk_size repl_time big_table disk_space osc_tool get_progress unique_key) #允许改表工单的哪些值被修改


#远程用户及命令相关, 会根据用户定义的type去选择使用什么来执行远程命令, 作为监控目标端的磁盘空间问题
ssh_type="msrt"                                                   #执行ssh的方式, 可以选msrt 
SSHOPTION="-o ConnectTimeout=6 -o ConnectionAttempts=5 -o PasswordAuthentication=no -o StrictHostKeyChecking=no "
ssh_user="root"                                                #用来ssh用户

#msrt相关
export PATH=${work_dir}/bin:$PATH                                 #将msrt加载到PATH
msrt_port="9298"
msrt_pass="myonlineddl"

#脚本中用到了一个特殊变量, '__' 这是临时变量, 作用域不超过5行代码
all_sql_is_ok="1"                                    #定义变量, 检查sql语句是否都符合要求, 如果有任何一部分不符合则置为0, 严谨模式则直接退出, 普通模式可以继续
one_sql_is_error="1"                                 #定义一个变量, 检查sql语句是否有错误, 如果有任何一部分不符合则置为0, 且直接退出
exe_time="10s"                                       #sql执行的时间, 超过10s表示异常
mysql_data_mount_dir="/"                        #数据分区挂载点
sql_exe_error="${log_file}.error.$(date +%N%s)"      #sql执行失败的失败信息临时保存文件
delete_mode_opt=1                                    #是否删除pt-osc/gh-ost生成的old表. 1 表示删除, 0 表示不删除, 默认是1
max_threads=2500                                     #限制mysql连接数, 当连接数大于这个值, 则暂停copy数据, dev环境是4000
max_running=64
max_thread_running=30                                #这个用来动态调整ghost的copy chunk size, 10就减少10%, 15就减少20%, 20就减少30%, 25就减少40%, 30就减少50%
mysql_role="master"                                  #该值是master的时候如果检查到主库又复制信息就退出了. 如果这种情况还需要改表就加上参数 -r slave
max_repl_time="60"                                   #允许从库最大延迟的秒数
max_chunk_size="3000"                                #拷贝的大小
ghost_wait_time="1800"                               #允许ghost最大等待时间, 如果使用ghost改表, 没有检查到标志改表产生的临时表, 且改表进程开始时间超过这个时间, 就视为异常
check_slave_repl="1"                                 #都检查哪些从库是否有延迟
t_order_drop="dbadb_drop_table_state_info"           #表名 删表工单记录保存的表, ${dbadb_port} 下的${dbadb_db},,, 下面的变量是这个表的字段列表
t_mdl_node="check_mdl_and_delay_for_node_info"       #保存mdl信息的node信息的表
t_state_task="dbadb_alter_table_repl_time"           #保存工单状态的表, 通过修改这个表的记录控制工单状态
t_prog_task="dbadb_alter_table_progress_info"        #保存工单进度, 程序自己更新
t_record_task="dbadb_alter_table_record_info"        #保存工单状态, 最终的状态
t_mysql_rs="mysql_ins_rs"                            #保存mysql rs的表
isdb="information_schema"                            #库名 information_schema
isdbp="${isdb}.PROCESSLIST"                          #表名 information_schema.PROCESSLIST
isdbc="${isdb}.COLUMNS"                              #表名 information_schema.COLUMNS
isdbt="${isdb}.TABLES"                               #表名 information_schema.TABLES
isdbtr="${isdb}.triggers"                            #表名 information_schema.triggers
isdbkcu="${isdb}.KEY_COLUMN_USAGE"                   #外键表
isdbss="${isdb}.STATISTICS"                          #检查索引类型表
isdbit="${isdb}.INNODB_TRX"                          #当前正在执行的事务
cmadi="check_mdl_and_delay_info"                     #保存发邮件状态表
cmadfmi="check_mdl_and_delay_for_mdl_info"           #保存mdl具体状态
cmadfcsi="check_mdl_and_delay_for_curexe_sql_info"   #当前执行的sql

first_check_mdl_mark=0                           #0和3表示检查一次mdl锁, 可以取0，1，2，3


#队列文件数组, 将队列列表放在数组里面
declare -a script_queue_file_list                                

#定义一个字典, 当遇到mdl的时候, 针对大小表等待的时间
declare -A meet_mdl_wait_time
meet_mdl_wait_time=(["N"]=0 ["S"]="900" ["M"]="1800" ["L"]="3600")  #N 小于10G的表, S大于10G, 小于100G的表, M小于500G的表, L大于500G的表, 分别对应等待的秒数

#定义一个数组保存在测试环境执行时遇到错误, 仅当-e等于0时有效
declare -a exe_sql_get_error_info_list 

#定义osc命令的参数列表
declare -a osc_array

#定义一个关联数组, 保存表名及列信息mysql_table["table1"]="col1 col2 col3"
declare -A mysql_table 

declare -A fk_info

#定义一个不存在的文件, 该文件作为判断某个表的改表任务是否存在, 预先定义一个不存在的文件
osc_run_mark_file="${script_execute_mark_dir}/$(date +%N%s).mark" 
declare -a osc_run_mark_file_list

#clean
. ${work_dir}/function/clean/f_exit.sh                           #退出函数, 会处理一些清理临时文件的工作

#log
. ${work_dir}/function/log/f_usage.sh                            #打印使用帮助信息
. ${work_dir}/function/log/f_logging.sh                          #日志模块
. ${work_dir}/function/log/f_archive_log.sh                      #日志归档

#prepare
. ${work_dir}/function/prepare/f_init_tmpvar.sh                   #初始化一些临时变量
. ${work_dir}/function/prepare/f_init_dir.sh                      #初始化一些必须的目录, 并归档日志

#check
. ${work_dir}/function/check/f_check_mode_opt.sh                  #准备工作, 根据用户的动作[mode_opt]具体执行哪个功能模块
. ${work_dir}/function/check/f_check_osc_or_alter.sh              #实现改表操作, 检查应该使用osc还是alter
. ${work_dir}/function/check/f_check_ssh_auth.sh                  #检查本机与master节点ssh是否正常, 包括slave
. ${work_dir}/function/check/f_check_work_is_run.sh               #检查某表改表进程是否在跑, 避免同个表有多个进程执行
. ${work_dir}/function/check/f_check_net_delay.sh                 #检查本机与master节点的网络延迟, 避免延迟高的场景还使用ghost进行改表

#monitor
. ${work_dir}/function/monitor/f_check_connect.sh                 #检查mysql连接数, 也是监控, 如果改表过程发现连接数太高就会暂停, 限流, 或者终止
. ${work_dir}/function/monitor/f_check_mdl_lock.sh                #检查mdl
. ${work_dir}/function/monitor/f_check_daemonize.sh               #所有监控任务的守护进程
. ${work_dir}/function/monitor/f_check_disk_space.sh              #检查各个节点的磁盘空间情况, 避免改表过程把磁盘写满
. ${work_dir}/function/monitor/f_check_mdl_lock_daemonize.sh      #检查mdl的守护进程, 如果检查到有mdl就会执行这个函数, 如果是ghost就会等待n秒（n是根据表大小递增）, 然后尝试继续改表, pt的话就直接终止

#dbadb
. ${work_dir}/function/dbadb/f_get_diy_info.sh                    #改表前先获取用户的diy配置, 如是否延迟, chunk size 是否允许大表改表, 最大连接数等等
. ${work_dir}/function/dbadb/f_check_diy_info.sh                  #动态获取用户的diy配置
. ${work_dir}/function/dbadb/f_print_progress_info.sh             #打印改表进度
. ${work_dir}/function/dbadb/f_record_info_to_dbadb.sh            #将异常的改表工单入库, 记录而已
. ${work_dir}/function/dbadb/f_update_status_for_task.sh          #这是修改工单状态的api, 动态修改用户的配置, 支持api的方式动态修改ghost的参数配置, 或者暂停, 终止等操作

#mysql
. ${work_dir}/function/mysql/f_exe_sql_in_mysql.sh                #执行sql
. ${work_dir}/function/mysql/f_check_mysql_info.sh                #检查mysql的元数据信息, 比如数据类型, 字符集, 存储引擎, 重复索引, 是否有主键等, 主要是针对建表前做的检查, 就是检查用户提交的create table语句是否符合规定
. ${work_dir}/function/mysql/f_create_test_env.sh                 #搭建测试环境
. ${work_dir}/function/mysql/f_delete_tmp_table.sh                #工单异常后清理临时表, 触发器等
. ${work_dir}/function/mysql/f_check_slave_status.sh              #检查从库的状态, 复制状态
. ${work_dir}/function/mysql/f_check_table_and_disk_size.sh       #检查表大小, 如果太大就不允许改, 检查master节点的磁盘空间, 如果不足两倍表大小就不允许改
. ${work_dir}/function/mysql/f_clear_table.sh                     #处理删表工单
. ${work_dir}/function/mysql/f_check_table_after_ddl.sh           #执行完用户的alter操作后检查表的是否满足规定, 跟这个函数有点类似f_check_mysql_info, 但是不同的是只针对修改的列做检查
. ${work_dir}/function/mysql/f_exe_osc_comm.sh                    #执行osc命令, 如果不满足onlineddl就使用osc进行改表(判断依据就是如果仅修改了元数据就直接使用alter进行操作)
. ${work_dir}/function/mysql/f_exe_alter_sql.sh                   #执行alter命令, 如果满足onlineddl就直接使用alter对数据库进行操作, 因为仅修改元数据的话是不用copy表的
. ${work_dir}/function/mysql/f_support_onlineddl.sh               #检查用户的change是否仅修改了元数据
. ${work_dir}/function/mysql/f_get_mysql_node_info.sh             #获取mysql节点信息, 根据用户的参数去dbadb去查master slave的ip, 如果用户有这种保存了线上所有数据库的信息库的话, 也可以使用-m进行制定master ip

#analysis
. ${work_dir}/function/analysis/f_delete_for_string.sh            #sql解析用到的一个功能模块, 就是格式化sql
. ${work_dir}/function/analysis/f_return_alter_sql.sh             #格式化sql
. ${work_dir}/function/analysis/f_get_create_sql.sh               #格式化sql
. ${work_dir}/function/analysis/f_get_alter_sql.sh                #解析alter操作
. ${work_dir}/function/analysis/f_get_sql_file.sh                 #获取用户的sql文件, 其实就是针对-f参数做一些检查以及保存sql文件

#osctool
. ${work_dir}/function/osctool/f_kill_process.sh                  #kill 连接, 如果遇到mdl就会先kill掉sleep状态的连接
. ${work_dir}/function/osctool/f_init_osc_opt.sh                  #遇到mdl就会kill掉改表进程(mdl不可处理的情况)

#mail
. ${work_dir}/function/mail/f_check_mdl_and_send_mail.sh          #遇到mdl的时候执行这个函数将mdl状态保存到库里, 并发送邮件
. ${work_dir}/function/mail/f_send_mail_for_alter_table_schedule.sh          #改表进度, 并发送邮件

[ "${#}x" == "0x" ] && f_usage
opt_error=0
while getopts :h:d:f:F:e:o:c:k:K:C:t:T:r:R:p:s:u:O:D:a:P:b:S:N:H OPTION
do  #处理参数
    case "$OPTION" in
        h)src_host=${OPTARG};;
        d)[ "${OPTARG}x" == "devx" ] && max_threads="4000";;  #如果指定了dev就将max_threads设置成4000, dev和pro的区别只有这个值不一样, 其余逻辑代码都一样
        f)sql_file=${OPTARG};;
        F)force=${OPTARG};;
        e)execute_opt=${OPTARG};;
        o)check_mode_opt=${OPTARG};;
        c)delete_mode_opt=${OPTARG};;
        k)support_foreign=${OPTARG};;
        K)sup_add_unique_key=${OPTARG};;
        C)force_tool=${OPTARG};;
        t)check_mdl_timeout=${OPTARG};;
        T)clear_table_list=${OPTARG};;
        r)mysql_role=${OPTARG};;
        R)max_repl_time=${OPTARG};;
        p)print_color=1;;
        s)check_slave_repl=${OPTARG};;
        u)user_mail_addr=${OPTARG};g_user_mail_addr="${user_mail_addr}";;
        O)user_order_id=${OPTARG};;
        D)mysql_db=${OPTARG};;
        a)mode_opt=${OPTARG};;
        P)mysql_port=${OPTARG};;
        b)mysql_big_table_mark=${OPTARG};;
        S)disk_space_mark=${OPTARG};;
        N)update_number=${OPTARG};;
        H)f_usage && exit 1;;
        :)echo -e "\n'-${OPTARG}' : 这个选项需要一个值";opt_error=1;;
        ?|*)echo -e "\n'-${OPTARG}' : 不识别这个选项.";opt_error=1;;
    esac
    if [ "$(grep -c -- "^-" <<< "${OPTARG}")x" == "1x" ]
    then
        echo -e "\n'-${OPTION}' : '${OPTARG}' 可能是个参数, 对于 '-${OPTION}' 来说 '${OPTARG}' 是非法的值, 需要给 '-${OPTION}' 指定一个合法的值"
        opt_error=1
    fi
done

[ "$(sed 's/[0-9]//g' <<< "${max_repl_time}")x" != "x" ] && opt_error=1 && echo -e "\n'-R ${max_repl_time}' : 这个选项需要一个整数值, 如 '-R 100'"
[ "${opt_error}x" == "1x" ] && f_usage

function f_main()
{
    if [ "${work_dir}/onlineddl.conf" ]
    then
        . ${work_dir}/conf/myonlineddl.conf
    fi
    declare fun_name
    fun_name="f_main"
    mysql_comm_dbadb="mysql --default-character-set=utf8 -u${mysql_user} -p${mysql_pass} -h${dbadb_host} -P${dbadb_port} ${dbadb_db}"
    which mysql >/dev/null 2>&1 || f_logging "ERROR:${fun_name}" "缺少必须的mysql工具[$(which mysql 2>&1|sed 's/which: //g')], 请及时安装" "2" "1"
    msrt="msrt --port ${msrt_port} --pass ${msrt_pass} --role client"            #定义msrt
    f_init_dir
    log_file_name="$(awk -F/ '{print $NF}' <<< "${0}"|awk -F. '{print $1".log"}'|awk -F/ '{print $NF}')"
    log_file="${script_execute_res_log_dir}/${log_file_name}"
    f_logging "REMAIND:${fun_name}" "开始执行 : '${s_n} ${*}'"
    f_get_sql_file
    f_check_mode_opt && exit 0 || { [ $? -eq 100 ] && true || exit 1;}
    f_get_mysql_node_info
    f_check_mysql_status
    f_check_net_delay
    f_prepare_mysqltest_env
    f_archive_log
    f_init_tmpvar
    html_file="${work_dir}/html/${user_order_id}.html"

    f_get_diy_info || true
    f_prepare || f_exit "1"    #在测试环境执行
    [ "${execute_opt}x" != "1x" ] && f_exit "0" #-e 选项对应的execute_opt参数如果不是1表示不会在线上执行
    f_exit "-1"                #测试环境执行通过后, 再到线上环境执行
    execute="1"                #线上环境执行的开关
    test_tmp_db="${test_db}"
    test_db="${test_db_exe}"
    test_db_exe="${test_tmp_db}"
    unset test_tmp_db
    f_prepare && exe_state="${?}" || exe_state="${?}"            #在线上环境执行
    if [ ${exe_state} -eq 100 ]
    then
        cat ${alter_file_tmp1} > ${alter_file_tmp2} 
        cat ${alter_file_tmp1}
        ghost_to_ptosc=1
        force_tool=0               #使用pt重新跑
        execute="0"                #线上环境执行的开关
        f_prepare || f_exit "1"    #在线上环境重新执行, 使用pt
        f_exit "-1"                #测试环境执行通过后, 再到线上环境执行
        execute="1"                #线上环境执行的开关
        f_prepare || f_exit "1"    #在线上环境重新执行, 使用pt
    fi
    f_exit "0"
}
f_main "${@}"              #预执行, 在测试环境执行, 某种程度上是保证sql脚本的原子性
